#!/usr/bin/env python

"""open - open a file with the appropriate application.

This module provides the single class Application. An Application
knows what files types it can process, and how it can process those
file types.

The module also provides a utility function "run" to find an
application to handle a file and launch it when the application
matches a given set of flags.

When run as a command, it usually takes a file name as an argument and
an option to indicate the action to take, finds the appropriate
application, and makes it happen. See the main function for usage
information."""

import os, sys
from subprocess import Popen
from optparse import OptionParser
import re

defaultrc = """# Internal application information
internal X ; ui ; internal gui module ; simplegui
internal noX ; ui ; internal cli module ; simplecli

"""

class OpenError(Exception):
    pass

class Application(object):
    """An Application takes a known action on a file.

    The only public attribute for this application is name, the name
    it should be known by."""

    def __init__(self, desc = None, flags = None, extensions = None,
                 name = None, command = None):
        """Create an application based on the passed in description.

        desc has the format "flags ; extensions ; name ; command". If it's
        passed in, the other arguments are ignored. Otherwise, they are
        strings as they would appear in the description as descibed below:

        flags is a white-space separated list of flags describing this
        Application and the action it takes.

        extensions is a white-space separated list of extensions used on files
        that this Application knows how to handle.

        flags and extensions are both case insensitive.

        name is a string to be used for the application in the user
        interface.

        command is the shell command line used by this Application to
        take the indicated action. If a %s shows up in the command,
        then the file name will be quoted to guard special characters
        from interpretation by the shell, and used as a second operand
        to a % and the resulting string passed to the
        shell. Otherwise, the command will be split on white space,
        the file name appended to the command, and the resulting array
        used as an argument list for exec."""

        if desc:
            flags, extensions, name, command = desc.split(';', 3)
        self.flags = flags.strip().lower().split()
        self.extensions =  extensions.strip().lower().split()
        self.name = name.strip()

        command = command.strip()
        self._shell = command.find('%s') >= 0
        if self._shell:
            self._command = command
        else:
            self._command = command.split()

    def handles(self, filename):
        """Returns true if this Application can handle filename."""

        return os.path.splitext(filename)[1][1:].lower() in self.extensions

    def has_flag(self, flag):
        """Returns true if this Application has the given flag."""

        return flag.lower() in self.flags

    def has_all(self, flags):
        """Returns true if this app has all the specified flags."""

        for flag in flags:
            if flag.lower() not in self.flags:
                return False
        return True

    def has_none(self, flags):
        """Returns true if this app has none of the specified flags."""

        for flag in flags:
            if flag.lower() in self.flags:
                return False
        return True

    def __call__(self, name):
        """Applies this Applications action to filename."""

        if self.handles(name):
            havecli = self.has_flag('noX') or not os.environ.has_key('DISPLAY')
            if havecli:
                # This application don't grok X, leave it's stdio streams
                # in place
                inf = outf = None
            else:
                outf = open('/dev/null', 'w')
                inf = open('/dev/null', 'r')

            proc = Popen(self.command(name), shell = self._shell,
                         stdin = inf, stdout = outf, stderr = outf)
            if havecli:
                proc.wait()


    def save(self, filename = None):
        """Writes my description out to the given filename."""

        if not filename:
            filename = os.path.join(os.environ['HOME'], '.openrc')
        rc = open(filename, 'a')
        if self._shell:
            com = self._command
        else:
            com = ' '.join(self._command)
        rc.write(' ; '.join((' '.join(self.flags), ' '.join(self.extensions),
                             self.name, com)))
        rc.write('\n')
                            
        rc.close()

    def command(self, name):
        """Returns the command that would be used to run filename.

        To avoid ambiguity, if the command is a list, you get a list back."""

        if self._shell:
            return self._command % self._quote(os.path.realpath(name))
        else:
            command = list(self._command) 
            command.append(name)
            return command

    badchar = re.compile('[][(){}<>;\'"~`\\|=*&^%$#@! ]')
    def _quote(self, name):
        """Put \\'s in front of all the possibly special characters."""

        return self.badchar.sub(self._backslash, name)
    
    def _backslash(self, match):
        """Returns the matched char with a backslash prepended to it."""

        return "\\" + match.group()

        
class ApplicationList(list):
    """A list of applications that can handle a file."""

    def __init__(self, filename = None, have = [], not_have = [], name = None):
        """Inits with optiona arguments that match finds arguments"""

        list.__init__(self)

        if os.environ.has_key('OPENERS'):
            self._openers = self._ok(
                os.environ['OPENERS'].decode('string_escape').split('\n'))
        else:
            self._openers = []
        
        rcfile = os.path.join(os.environ['HOME'], '.openrc')
        try:
            rc  = open(rcfile, 'r')
        except IOError:
            rc = open(rcfile, 'w+')
            rc.write(defaultrc)
            rc.seek(0)
            
        self._openrc = self._ok(rc)
        rc.close()

        if filename:
            self.find(filename, have, not_have, name)

    def _ok(self, data):
        """Turn a string of description lines into a list of Applications."""

        out = []
        for line in data:
            clean = line.strip()
            if clean and clean[0] != '#':
                out.append(Application(desc = clean))
        return out

    def find(self, filename, have = [], not_have = [], name = None):
        """Actually populate the list with applications that can handle file.

        have is a list of keywords the application must have.
        not_have is a list it musn't have."""

        self._filename, self._name = filename, name
        self.have, self.not_have = have, not_have

        applist = self._filter(self._openers)
        if not applist:
            applist = self._filter(self._openrc)
        self[:] = applist

    def _filter(self, apps):
        return [app for app in apps if app.handles(self._filename)
                       and app.has_all(self.have) and app.has_none(self.not_have)
                       and not (self._name and app.name != self._name) ]

def find(filename, name = None, have = None, not_have = None):
    """Find an application to run filename with the given constraints.

    This will interact with the user if need be."""

    if not have:
        have = []
    if not not_have:
        if 'internal' in have:
            not_have = []
        else:
            not_have = ['internal']

    # Sniff the environment to set up X usage, if it's not already set
    if not ('X' in have or 'noX' in have):
        if os.environ.has_key('DISPLAY'):
            not_have.append('noX')
        else:
            not_have.append('X')

    applist = ApplicationList(filename, name = name, have = have,
                              not_have = not_have)

    if len(applist) == 1:
        return applist[0]

    # We found more than one application, or none. Either way, get have
    # to ask the user for help. So get a UI.
    ui_have = ['internal']
    ui_not_have = list()
    if 'X' in have:
        ui_have.append('X')
    elif 'X' in not_have:
        ui_not_have.append('X')
    elif 'noX' in have:
        ui_have.append('noX')
    elif 'noX' in not_have:
        ui_not_have.append('noX')
    guifinder = ApplicationList('open.ui', have = ui_have,
                                not_have = ui_not_have)

    if not guifinder:
        # Can't talk to the user - raise an exception.
        raise OpenError, "Can't find a user interface."
    gui = __import__('open.%s' % guifinder[0]._command[0], globals(),
                     None, [1])

    if not applist:
        return gui.find(filename, have)
    else:
        return gui.choose(filename, applist)


def run(filename, name = None, have = [], not_have = []):
    """Convenience function to run an application on a file."""

    find(filename, name = name, have = have, not_have = not_have)(filename)


def main():
    """Parse the command line, pass the results to find, then run the app."""

    parser = OptionParser(usage = 'usage: %prog [--edit | --print] [ --application name ] [ --no-run ] [ -X |  -noX ] path',
                          version = "%prog 0.3")
    parser.set_defaults(edit = False, no_run = False, verbose = False, have = [],
                        not_have = [], command = os.path.basename(sys.argv[0]))
    parser.add_option('-c', '--command', action='store',
                      help = 'command to perform on the file.')
    parser.add_option('-e', action = 'store_const', const = 'edit',
                      dest = 'command',
                      help = 'edit the file; shorthand for -c edit')
    parser.add_option('-p', action = 'store_const', const = 'print',
                      dest = 'command',
                      help = 'print the file; shorthand for -c print')
    parser.add_option('-n', '--no-run', action = 'store_true',
                       help = 'Print the command instead of running it')
    parser.add_option('-X', '--X', action = 'store_true',
                      help = 'Require an X application; shorthand for -f X')
    parser.add_option('--noX', action = 'store_false', dest = 'X',
                      help = 'Require a non-X application; shorthand for -f noX')
    parser.add_option('-a', '--application', type = 'string', dest = 'name',
                      help = 'Name of application to use')
    parser.add_option('-v', '--verbose', action = 'store_true',
                      help = "Print the command before runing it.")
    parser.add_option('-f', '--flag', action = 'append', dest = 'have',
                      help = 'Specify a flag that the Application must have')
    parser.add_option('-F', '--noflag', action = 'append', dest = 'not_have',
                      help = 'Specify a flag that the Application must not have')
    opts, args = parser.parse_args()

    if len(args) != 1:
        parser.error('a single path is required.')
        sys.exit(2)

    # OptionParser doesn't detect the presence of both -X and
    # --noX, or -e, -p and -c. We need to tweak things later to catch
    # these.

    have = opts.have + [opts.command]
    not_have = opts.not_have

    # ignore internal apps unless the user specifies it somewhere.
    if 'internal' not in not_have and 'internal' not in have:
        not_have.append('internal')
        
    # opts.X is tristated: True (must run under X), False (must run
    # without X), and None (don't care)
    if opts.X is False:
        have.append('noX')
    elif opts.X:
        have.append('X')

    path = args[0]
    app = find(path, name = opts.name, have = have, not_have = not_have)
    if opts.no_run or opts.verbose:
        print app.command(path)
    if not opts.no_run:
        app(path)
    sys.exit(0)

    

if __name__== '__main__':
    main()
